feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803
feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803mnriem wants to merge 21 commits intogithub:mainfrom
Conversation
…github#1752) Bundle templates, commands, and scripts inside the specify-cli wheel so that `specify init` works without any network access by default. Changes: - pyproject.toml: add hatchling force-include for core_pack assets; bump version to 0.2.1 - __init__.py: add _locate_core_pack(), _generate_agent_commands() (Python port of generate_commands() shell function), and scaffold_from_core_pack(); modify init() to scaffold from bundled assets by default; add --from-github flag to opt back in to the GitHub download path - release.yml: build wheel during CI release job - create-github-release.sh: attach .whl as a release asset - docs/installation.md: add Enterprise/Air-Gapped Installation section - README.md: add Option 3 enterprise install with accurate offline story Closes github#1711 Addresses github#1752
There was a problem hiding this comment.
Pull request overview
This PR makes specify init work offline by bundling the core template pack (templates/commands/scripts) inside the specify-cli wheel, and updates the release workflow/docs to support air-gapped installation via a published .whl release asset.
Changes:
- Bundle core templates/commands/scripts into the wheel and scaffold from those assets by default (with
--from-githubto force network download). - Add runtime generation of agent-specific command files (md/toml/agent.md + Copilot prompt companions).
- Publish the wheel as a GitHub release asset and document enterprise/air-gapped install steps.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds core-pack discovery, offline scaffolding, and agent command generation; updates init to be offline-first with --from-github. |
pyproject.toml |
Bumps version and force-includes core assets into the wheel. |
docs/installation.md |
Adds enterprise/air-gapped installation instructions and offline init guidance. |
README.md |
Documents the new air-gapped installation option via wheel. |
CHANGELOG.md |
Notes offline-first init, --from-github, and wheel release asset. |
.github/workflows/scripts/create-github-release.sh |
Attaches the built wheel to GitHub releases. |
.github/workflows/release.yml |
Adds a wheel build step to the release workflow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-1752 # Conflicts: # CHANGELOG.md # pyproject.toml # src/specify_cli/__init__.py
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:1016
- The
_generate_agent_commands()docstring states TOML output is for "Gemini/Qwen/Tabnine", but Qwen is configured/packaged as Markdown commands (not TOML). Please update the docstring to match the actual supported formats so it stays consistent withcreate-release-packages.shand the existing tests.
"""Generate agent-specific command files from Markdown command templates.
Python equivalent of the generate_commands() shell function in
.github/workflows/scripts/create-release-packages.sh. Handles Markdown,
TOML (Gemini/Qwen/Tabnine), and .agent.md (Copilot) output formats.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Embed release scripts (bash + PowerShell) in wheel via pyproject.toml - Replace Python _generate_agent_commands() with subprocess invocation of the canonical create-release-packages.sh, guaranteeing byte-for-byte parity between 'specify init --offline' and GitHub release ZIPs - Fix macOS bash 3.2 compat in release script: replace cp --parents, local -n (nameref), and mapfile with POSIX-safe alternatives - Fix _TOML_AGENTS: remove qwen (uses markdown per release script) - Rename --from-github to --offline (opt-in to bundled assets) - Add _locate_release_script() for cross-platform script discovery - Update tests: remove bash 4+/GNU coreutils requirements, handle Kimi directory-per-skill layout, 576 tests passing - Update CHANGELOG and docs/installation.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
src/specify_cli/init.py:1534
- PR description/docs indicate init should be offline-first with an explicit opt-in to GitHub (e.g.
--from-github), but the current docstring/implementation says GitHub is the default and--offlineis opt-in. Please align the CLI flags/defaults with the intended UX (or update the PR/docs accordingly) to avoid confusing air-gapped users.
By default, project files are downloaded from the latest GitHub release.
Use --offline to scaffold from assets bundled inside the specify-cli
package instead (no internet access required, ideal for air-gapped or
enterprise environments).
src/specify_cli/init.py:1526
- The new
--offlineinit path isn't covered by CLI-level tests. Consider adding aCliRunnertest that invokesspecify init ... --offlinewithscaffold_from_core_packmocked and assertsdownload_and_extract_templateis not called (and that failures don't trigger network attempts when offline is requested).
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required)"),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
tests/test_core_pack_scaffold.py:216
- These tests call
scaffold_from_core_pack()separately in many parametrized test cases, and that function spawns the release script each time. With ~N agents this becomes O(N * tests) subprocess invocations and can significantly slow CI. Consider a session-scoped fixture that scaffolds once per agent (per script type) into tmp dirs and reuses the resulting trees across invariant tests.
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_creates_specify_scripts(tmp_path, agent):
"""scaffold_from_core_pack copies at least one script into .specify/scripts/."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'"
scripts_dir = project / ".specify" / "scripts" / "bash"
assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'"
assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'"
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_creates_specify_templates(tmp_path, agent):
"""scaffold_from_core_pack copies at least one page template into .specify/templates/."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok
tpl_dir = project / ".specify" / "templates"
assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'"
assert any(tpl_dir.iterdir()), ".specify/templates/ is empty"
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems):
"""Command files land in the directory declared by AGENT_CONFIG."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok
.github/workflows/release.yml:46
- The new wheel build writes into
.genreleases/, butcreate-release-packages.shclears.genreleases/*at the start of its run. With the current step order, the wheel will be deleted before the release is created, socreate-github-release.shwon't be able to attach it. Build the wheel aftercreate-release-packages.sh, or output the wheel to a different directory that isn’t wiped (or adjust the script to not delete unrelated artifacts).
- name: Build Python wheel
if: steps.check_release.outputs.exists == 'false'
run: |
pip install build
python -m build --wheel --outdir .genreleases/
- name: Create release package variants
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/create-release-packages.sh
.github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}
src/specify_cli/init.py:1002
_locate_core_pack()’s docstring claims it falls back to source-checkout/editable install paths, but the implementation only checksPath(__file__).parent / "core_pack"and otherwise returnsNone. Either implement the documented fallback behavior here (e.g., detect repo-root assets) or update the docstring so callers don’t rely on behavior that doesn’t exist.
def _locate_core_pack() -> Path | None:
"""Return the filesystem path to the bundled core_pack directory.
Works for wheel installs (hatchling force-include puts the directory next to
__init__.py as specify_cli/core_pack/) and for source-checkout / editable
installs (falls back to the repo-root templates/ and scripts/ trees).
Returns None only when neither location exists.
"""
# Wheel install: core_pack is a sibling directory of this file
candidate = Path(__file__).parent / "core_pack"
if candidate.is_dir():
return candidate
return None
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… network - _locate_core_pack() docstring now accurately describes that it only finds wheel-bundled core_pack/; source-checkout fallback lives in callers - init() --offline + no bundled assets now exits with a clear error (previously printed a warning and silently fell back to GitHub download) - init() scaffold failure under --offline now exits with an error instead of retrying via download_and_extract_template Addresses reviewer comment: github#1803
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
tests/test_core_pack_scaffold.py:503
- This fixture runs the release script for every agent and extracts ZIPs, but it does not clean up the generated
.genreleases/spec-kit-template-*.zipartifacts in the repo. Please add teardown cleanup (e.g., delete the specific ZIPs or the.genreleasescontents after extraction) so local test runs don’t accumulate large build outputs.
tmp = tmp_path_factory.mktemp("release_script")
extracted: dict[str, Path] = {}
for agent in _TESTABLE_AGENTS:
zip_path = _run_release_script(agent, "sh", tmp, bash)
dest = tmp / f"extracted-{agent}"
dest.mkdir()
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(dest)
extracted[agent] = dest
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(shell): harden validate_subset against glob injection in case patterns - fix(shell): make GENRELEASES_DIR overridable via env var for test isolation - fix(cli): probe pwsh then powershell on Windows instead of hardcoding pwsh - fix(cli): remove unreachable fallback branch when --offline fails - fix(cli): improve --offline error message with common failure causes - fix(release): move wheel build step after create-release-packages.sh - fix(docs): add --offline to installation.md air-gapped example - fix(tests): remove unused genreleases_dir param from _run_release_script - fix(tests): rewrite parity test to run one agent at a time with isolated temp dirs, preventing cross-agent interference from rm -rf
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(shell): replace case-pattern membership with explicit loop + == check for unambiguous glob-safety in validate_subset() - fix(cli): require pwsh (PowerShell 7) only; drop powershell (PS5) fallback since the bundled script uses #requires -Version 7.0 - fix(cli): add bash and zip preflight checks in scaffold_from_core_pack() with clear error messages if either is missing - fix(build): list individual template files in pyproject.toml force-include to avoid duplicating templates/commands/ in the wheel
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add 120s timeout to subprocess.run in scaffold_from_core_pack to prevent indefinite hangs during offline scaffolding - Add test_pyproject_force_include_covers_all_templates to catch missing template files in wheel bundling - Tighten kiro alias test to assert specific scaffold path (download vs offline)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(offline): use handle_vscode_settings() merge for --here --offline to prevent data loss on existing .vscode/settings.json - fix(release): glob wheel filename in create-github-release.sh instead of hardcoding version, preventing upload failures on version mismatch - docs(release): add comment noting pyproject.toml version is synced by release-trigger.yml before the tag is pushed
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(offline): pwsh-only, no powershell.exe fallback; clarify error message - fix(offline): tighten _has_bundled to check scripts dir for source checkouts - feat(release): build specify-bundle-v*.zip with all deps at release time - feat(release): attach offline bundle ZIP to GitHub release assets - docs: simplify air-gapped install to single ZIP download from releases - docs: add Windows PowerShell 7+ (pwsh) requirement note
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
tests/test_core_pack_scaffold.py:276
- Several tests call scaffold_from_core_pack() but never assert it succeeded (or that the expected command directory exists). If scaffolding fails, cmd_dir.rglob(...) yields no files and the test can pass vacuously, masking regressions. Add an explicit
assert okand/orassert cmd_dir.is_dir()before iterating files here (and apply the same pattern to the other invariant tests in this file).
project = tmp_path / "proj"
scaffold_from_core_pack(project, agent, "sh")
cmd_dir = _expected_cmd_dir(project, agent)
for f in cmd_dir.rglob("*"):
if f.is_file():
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, (
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add timeout=300 and returncode check to _run_release_script() to fail fast with clear output on script hangs or failures - Remove unused import specify_cli, _SOURCE_TEMPLATES, bundled_project fixture - Add session-scoped scaffolded_sh/scaffolded_ps fixtures that scaffold once per agent and reuse the output directory across all invariant tests - Reduces test_core_pack_scaffold runtime from ~175s to ~51s (3.4x faster) - Parity tests still scaffold independently for isolation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codex now uses create_skills() with hyphenated separator (speckit-plan/SKILL.md)
instead of generate_commands(). Update _SKILL_AGENTS, _expected_ext, and
_list_command_files to handle both codex ('-') and kimi ('.') skill agents.
Also picks up iflow as a new testable agent automatically via AGENT_CONFIG.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:1922
- This
--offlinepreflight error suggests "run from the repo root" for source checkouts, but the code resolvestemplates/andscripts/relative to__file__(the installed module path), not the current working directory. That guidance may mislead users (changing CWD won't fix missing assets). Consider adjusting the message to instead explain that offline mode requires either a wheel withcore_pack/or a source checkout that includes thetemplates/andscripts/directories alongside the package code.
"\n[red]Error:[/red] --offline was specified but no bundled assets were found.\n"
" • Wheel install: reinstall the specify-cli wheel (core_pack/ must be present).\n"
" • Source checkout: run from the repo root so templates/ and scripts/ are accessible.\n"
"Remove --offline to attempt a GitHub download instead."
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…eckout fallback --offline now strictly requires _locate_core_pack() to find the wheel's bundled core_pack/ directory. Source-checkout fallbacks are no longer accepted at the init() level — if core_pack/ is missing, the CLI errors out with a clear message pointing to the installation docs. scaffold_from_core_pack() retains its internal source-checkout fallbacks so parity tests can call it directly from a source checkout.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| else: | ||
| repo_root = Path(__file__).parent.parent.parent | ||
| templates_dir = repo_root / "templates" | ||
|
|
| verify = not skip_tls | ||
| local_ssl_context = ssl_context if verify else False | ||
| local_client = httpx.Client(verify=local_ssl_context) | ||
|
|
CHANGELOG.md
Outdated
| - feat(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init --offline` uses bundled assets without network access (#1711) | ||
| - feat(cli): add `--offline` flag to `specify init` to scaffold from bundled assets instead of downloading from GitHub (for air-gapped/enterprise environments) | ||
| - feat(cli): embed release scripts (bash + PowerShell) in wheel and invoke at runtime for guaranteed parity with GitHub release ZIPs | ||
| - feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) |
…x.Client to download path - Remove entire [Unreleased] section — CHANGELOG is auto-generated at release - Move httpx.Client into use_github branch with context manager so --offline path doesn't allocate an unused network client
Summary
Closes #1711
Addresses #1752
Embeds templates, commands, and scripts inside the
specify-cliPython wheel so thatspecify init --offlineworks with zero network access.Problem
Two related air-gapped blockers were addressed together:
.whlwas available as a release assetspecify init(feat(cli): Embed core template pack in CLI package for air-gapped deployment #1711) —api.github.comis blocked; the init command unconditionally callsdownload_template_from_github()to fetch a release ZIPA user who solved problem 1 (offline pip install) would immediately hit problem 2 on first use.
Solution
1. Bundle core assets in the wheel (
pyproject.toml)2. New
--offlineflag forspecify initBy default,
specify initdownloads project files from the latest GitHub release (unchanged behavior). The new--offlineflag opts in to using bundled assets instead:If
--offlineis specified but bundled assets cannot be found or scaffolding fails, the CLI errors out with a clear message rather than silently falling back to a network download.3. Offline scaffold via release script (
scaffold_from_core_pack)_locate_core_pack()— finds bundled assets (wheel install) or returns None_locate_release_script()— finds the platform-appropriate release script; on Windows requirespwsh(PowerShell 7+); Windows PowerShell 5.x (powershell.exe) is not supportedscaffold_from_core_pack()— invokes the bundledcreate-release-packages.sh(or.ps1) in a temp directory to generate the exact same output as the GitHub release ZIPs, then copies the result to the project directory--here),.vscode/settings.jsonis merged viahandle_vscode_settings()(JSONC-safe) instead of overwritten4. Air-gapped installation via
pip downloadUsers on a connected machine with the same OS and Python version as the target run
pip downloadto collect the wheel and all dependencies into a portable directory. This directory is transferred to the air-gapped machine and installed withpip install --no-index. No wheel or bundle ZIP is published as a release asset —pip downloadhandles OS-specific dependency resolution correctly.5. Shell script improvements
GENRELEASES_DIRis now overridable via environment variable (defaults to.genreleases), enabling tests to write to temp dirs instead of polluting the repovalidate_subset()hardened against glob injection incasepatterns6. Documentation
docs/installation.md: new "Enterprise / Air-Gapped Installation" section with step-by-steppip downloadworkflowREADME.md: "Option 3: Enterprise / Air-Gapped Installation" linking to the full guideAcceptance criteria from #1711
specify init --offlinescaffolds from embedded assets with no network callsspecify init(no--offline) retains current GitHub-download behaviorpip install specify-cliincludes all core templates, commands, and scriptsforce-includein pyproject.toml)create-release-packages.shcontinues to workpip downloadon connected machine → transfer →pip install --no-index→specify init --offline)--here --offlinemerges settings.json instead of overwritingpwsh(PowerShell 7+) onlyTesting
Includes parity tests (one per agent) verifying byte-for-byte equivalence between
scaffold_from_core_pack()output and the canonical release script ZIP.